Skip to content

feat(workflows): expose {{ context.run_id }} template variable#2664

Merged
mnriem merged 2 commits into
github:mainfrom
doquanghuy:feat/expose-run-id-template-var
May 27, 2026
Merged

feat(workflows): expose {{ context.run_id }} template variable#2664
mnriem merged 2 commits into
github:mainfrom
doquanghuy:feat/expose-run-id-template-var

Conversation

@doquanghuy
Copy link
Copy Markdown
Contributor

Description

Closes #2590.

Surfaces the engine-assigned run id (the same 8-character hex
string Spec Kit prints as Run ID: at the end of
workflow run) as a workflow template variable so YAML authors
can reference it from shell run:, command input.args:,
switch expression:, and any other field that already evaluates
{{ ... }} templates.

This is shape A from the issue ({{ context.run_id }}) —
the most discoverable option and consistent with the existing
inputs.* / steps.X.output.* naming.

Why

The run id is the natural join key between a Spec Kit workflow
run and downstream artifacts, telemetry, or per-run scratch
state. Today the operator sees it in stdout but workflows
themselves cannot reference it — there was no way to stamp a
log line, name a scratch directory, or tag an artifact with the
same id Spec Kit assigned.

The three use cases from the issue:

  1. Telemetry / observability — stamp logs and events with
    the run id so external systems can join workflow runs to
    downstream artifacts.
  2. Per-run scratch / isolation — interactive operator
    commands that need their own state directory under
    /tmp/run-<id>/.
  3. Run-id in artifact metadata — stable join key from
    artifact back to the producing run.

Canonical usage

# Stamp telemetry events with the run id for cross-system join.
- id: emit-event
  type: shell
  run: 'echo "{\"run_id\":\"{{ context.run_id }}\",\"event\":\"started\"}" >> events.jsonl'

# Per-run scratch directory.
- id: prep-scratch
  type: shell
  run: 'mkdir -p /tmp/run-{{ context.run_id }}'

# Pass run id into a command for artifact metadata.
- id: tag-artifact
  command: speckit.specify
  input:
    args: "{{ context.run_id }}"

Implementation

StepContext.run_id is already populated by WorkflowEngine
in both execute() and resume(). The only gap was the
template namespace builder.

_build_namespace (in workflows/expressions.py) now adds a
context key alongside the existing inputs, steps, item,
and fan_in namespaces:

ns["context"] = {"run_id": run_id}

The value is always present (even outside a run) and falls back
to an empty string when no run is active. Workflows referencing
{{ context.run_id }} therefore never error — a hard
requirement from the issue's acceptance criteria for dry-run,
validation, and ad-hoc evaluator usage.

Default behaviour preserved

Workflows that do not reference {{ context.run_id }} are
byte-equivalent to before this change. The context namespace
is added unconditionally to keep template resolution
branch-free, but its presence has no observable effect when
nothing references it.

Testing

  • Tested locally with uv run specify --help
  • Ran existing tests with uv sync && uv run pytest
    2967 passed, 35 skipped (was 2960 before; +7 new
    tests added in this PR).
  • Tested with a sample workflow: ran a shell step with
    run: 'echo "RUN_ID={{ context.run_id }}"' and confirmed
    the captured stdout matches the Run ID: line Spec Kit
    prints at the end of workflow run. Re-ran without the
    template reference and the workflow behaved identically
    to pre-PR.

New test coverage

TestExpressions (unit-level):

Test What it locks
test_context_run_id_resolves Direct lookup against StepContext(run_id=...).
test_context_run_id_defaults_to_empty_when_unset Graceful default outside a run context (no error).
test_context_run_id_string_interpolation Mixed template like "RUN_ID={{ context.run_id }}".

TestContextRunId (end-to-end), covering the three step types the issue's acceptance criteria called out:

Test What it locks
test_shell_run_resolves_run_id run: field substitution, verified via captured stdout.
test_command_input_args_resolves_run_id input.args: resolution, captured in step output even when CLI dispatch is unavailable (the artifact-metadata use case).
test_switch_expression_matches_on_run_id Switch matches against the resolved value, proving the run id is a first-class value in the expression engine.
test_workflow_without_context_reference_unchanged Locks the byte-equivalent default for workflows that don't use the variable.

AI Disclosure

  • I did not use AI assistance for this contribution
  • I did use AI assistance (described below)

Used Claude Opus to draft the namespace change, the test suite,
the docs section, and this PR body. The shape
({{ context.run_id }} with empty-string fallback) was
proposed in the issue body; this PR implements that proposal.
Code, tests, and design decisions were human-reviewed before
submission.

@doquanghuy doquanghuy requested a review from mnriem as a code owner May 21, 2026 15:04
@doquanghuy doquanghuy force-pushed the feat/expose-run-id-template-var branch from 9940396 to 68634d8 Compare May 21, 2026 15:11
@mnriem mnriem requested a review from Copilot May 26, 2026 12:19
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@mnriem mnriem requested a review from Copilot May 27, 2026 12:06
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new

@doquanghuy
Copy link
Copy Markdown
Contributor Author

doquanghuy commented May 27, 2026

@mnriem — quick status on this one: no outstanding review comments (the 2026-05-27 Copilot pass reported 0 new findings), and I've now rebased the branch onto current main to keep it in sync (clean rebase, no conflicts). Full test suite still passes — 7/7 on the new TestContextRunId and TestExpressions cases for {{ context.run_id }}.

The failing checks visible on the PR are not a code issue — they're a transient GitHub Actions infrastructure failure from 2026-05-26 12:19 UTC (the runs were queued 2026-05-21 in action_required and started executing the moment a maintainer approved, ~5 days later). Every job (ruff, markdownlint, all six pytest matrix entries, both CodeQL jobs) exited within 2–3 seconds with:

##[error]An action could not be found at the URI
'https://codeload.github.com/astral-sh/setup-uv/tar.gz/08807647e7069bb48b6ef5acd8ec9567f424441b'
##[error]Failed to download archive '…' after 1 attempts.

That SHA is the pin for astral-sh/setup-uv@v8.1.0 — referenced identically by main and every PR branch, so this was a fleet-wide outage of codeload.github.com serving that one tarball, not anything specific to this branch. The pin resolves cleanly again now (HTTP/2 200). The force-push from the rebase should have triggered fresh CI runs that will sit in action_required until you approve — those should now pass.

Branch is MERGEABLE and ready for another look whenever you have a moment.

AI disclosure: drafted with Claude Opus, human-reviewed.

Closes github#2590.

Surfaces the engine-assigned run id (the same 8-character hex
string Spec Kit prints as `Run ID:` at the end of
`workflow run`) as a workflow template variable so YAML
authors can reference it from shell `run:`, command
`input.args:`, switch `expression:`, and any other field that
already evaluates `{{ ... }}` templates.

### Why

The run id is the natural join key between a Spec Kit workflow
run and downstream artifacts, telemetry, or per-run scratch
state. Today the operator sees it in stdout but workflows
themselves cannot reference it — there was no way to stamp a
log line, name a scratch directory, or tag an artifact with
the same id Spec Kit assigned.

The three motivating use cases from the issue:

1. Telemetry / observability — stamp logs and events with the
   run id so external systems can join workflow runs to
   downstream artifacts.
2. Per-run scratch / isolation — interactive operator commands
   that need their own state directory under
   `/tmp/run-<id>/`.
3. Run-id in artifact metadata — stable join key from artifact
   back to the producing run.

### Implementation

`StepContext.run_id` is already populated by `WorkflowEngine`
in both `execute()` and `resume()`. The only gap was the
template namespace builder.

`_build_namespace` (in `workflows/expressions.py`) now adds a
`context` key alongside the existing `inputs`, `steps`,
`item`, and `fan_in` namespaces:

```python
ns["context"] = {"run_id": run_id}
```

The value is always present (even outside a run) and falls
back to an empty string when no run is active. Workflows
referencing `{{ context.run_id }}` therefore never error — a
hard requirement from the issue's acceptance criteria for
dry-run, validation, and ad-hoc evaluator usage.

### Default behaviour preserved

Workflows that do not reference `{{ context.run_id }}` are
byte-equivalent to before this change. The `context`
namespace is added unconditionally to keep template
resolution branch-free, but its presence has no observable
effect when nothing references it.

### Tests

`TestExpressions` (unit-level) gains three tests:

- `test_context_run_id_resolves` — direct lookup against a
  `StepContext(run_id=...)`.
- `test_context_run_id_defaults_to_empty_when_unset` —
  graceful default outside a run context.
- `test_context_run_id_string_interpolation` — mixed
  template (e.g. `"RUN_ID={{ context.run_id }}"`).

`TestContextRunId` (end-to-end) covers the three step types
the acceptance criteria called out:

- `test_shell_run_resolves_run_id` — `run:` field
  substitution, verified via captured stdout.
- `test_command_input_args_resolves_run_id` — `input.args:`
  resolution, captured in step output even when CLI dispatch
  is unavailable (the artifact-metadata use case).
- `test_switch_expression_matches_on_run_id` — switch
  matches against the resolved value, proving the run id is a
  first-class value in the expression engine, not just an
  interpolation token.
- `test_workflow_without_context_reference_unchanged` —
  locks the byte-equivalent default required by the issue.

### Docs

`workflows/README.md` gains a "Runtime Context" subsection
under "Expressions" documenting the new namespace and the
three canonical use patterns (telemetry, per-run scratch,
artifact metadata).
@doquanghuy doquanghuy force-pushed the feat/expose-run-id-template-var branch from 68634d8 to 20b71ba Compare May 27, 2026 12:59
@mnriem mnriem requested a review from Copilot May 27, 2026 16:16
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new

Copy link
Copy Markdown
Collaborator

@mnriem mnriem left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Windows tests errors

`test_shell_run_resolves_run_id` and
`test_switch_expression_matches_on_run_id` used
`run: 'echo "RUN_ID={{ context.run_id }}"'` with inner double-quotes
around the echo argument. Bash/sh strips those quotes before invoking
echo, but cmd.exe (used on Windows when `shell=True`) treats them
as literal characters and emits `"RUN_ID=abc12345"` — failing the
exact-match assertion. Linux passed; all three Windows-latest matrix
entries failed with `assert '"RUN_ID=abc12345"' == 'RUN_ID=abc12345'`.

Resolve by dropping the inner double-quotes (the value has no spaces
or shell metacharacters) and wrapping the YAML scalar in plain
double-quotes the same way other shell-step tests in this file do
(e.g. `run: "echo b-saw-..."`). Behaviour-equivalent on POSIX,
portable to cmd.exe.
@doquanghuy
Copy link
Copy Markdown
Contributor Author

@mnriem — Windows pytest failure fixed in c144144.

Root cause. Two tests in TestContextRunId used inner double-quotes inside the YAML run: value:

run: 'echo "RUN_ID={{ context.run_id }}"'

Bash/sh strips those inner double-quotes before invoking echo, so the stdout assertion == 'RUN_ID=abc12345' passes on Linux. cmd.exe (used on Windows when subprocess.run(..., shell=True)) treats them as literal characters and emits "RUN_ID=abc12345" — quotes and all — failing the assertion. All 3 Ubuntu matrix entries passed; all 3 Windows-latest entries failed with:

E   assert '"RUN_ID=abc12345"' == 'RUN_ID=abc12345'
E   assert '"nested-run-id=target-run"' == 'nested-run-id=target-run'

(The Windows-3.13 job log shows a KeyboardInterrupt + "operation was canceled" at the tail — that's the fail-fast cancellation kicking in after 3.11/3.12 had already failed on the same two tests, not a separate issue.)

Fix. Drop the inner double-quotes (the value has no spaces or shell metacharacters that need them) and wrap the YAML scalar in plain double-quotes the same way other shell-step tests in tests/test_workflows.py already do (e.g. run: "echo b-saw-{{ steps.step-a.output.stdout }}"). Behaviour-equivalent on POSIX, portable to cmd.exe.

Diff is two lines:

-    run: 'echo "RUN_ID={{ context.run_id }}"'
+    run: "echo RUN_ID={{ context.run_id }}"
...
-          run: 'echo "nested-run-id={{ context.run_id }}"'
+          run: "echo nested-run-id={{ context.run_id }}"

Full TestContextRunId + TestExpressions run_id suite: 7/7 pass locally. Pushed to origin/feat/expose-run-id-template-var.

AI disclosure: drafted with Claude Opus, human-reviewed.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new

@mnriem mnriem self-requested a review May 27, 2026 18:00
@mnriem mnriem merged commit c6afe4c into github:main May 27, 2026
11 checks passed
@mnriem
Copy link
Copy Markdown
Collaborator

mnriem commented May 27, 2026

Thank you!

doquanghuy added a commit to doquanghuy/spec-kit that referenced this pull request May 27, 2026
Adds an optional `continue_on_error: bool` field on every step.
When set to `true` and the step fails, the engine records the
result (`exit_code`, `stderr` on `steps.<id>.output` plus `status`
as a sibling key on `steps.<id>`) and continues to the next sibling
step instead of halting the run. Downstream `if`, `switch`, or
`gate` steps can then branch on
`{{ steps.<id>.output.exit_code }}` to route the recovery path.

Engine details
--------------
`WorkflowEngine._execute_steps` now consults the step config when a
step returns `StepStatus.FAILED`:

- Gate aborts (`output.aborted`) always halt the run — operator
  decisions take precedence over the flag.
- Otherwise, if `continue_on_error` is the literal `True`, log a
  `step_continue_on_error` event and proceed to the next sibling.
  The runtime check uses identity comparison (`is True`) rather
  than truthiness, so truthy non-bool values like the string
  `"true"` cannot silently change run semantics even if a caller
  bypasses `validate_workflow()`.
- Otherwise, behave as before: log `step_failed`, set
  `RunStatus.FAILED`, and return.

Validation
----------
`_validate_steps` rejects non-bool values for `continue_on_error`.
Coerced strings like `"true"` are not accepted so authoring
mistakes surface at validation time rather than silently changing
run semantics.

Tests
-----
`TestContinueOnError` in `tests/test_workflows.py` (8 tests):
- `test_undeclared_failure_halts_run` — default halt behaviour.
- `test_declared_and_fired_continues_run` — flag + fail → continue.
- `test_declared_but_step_succeeded_is_noop` — flag + success → no-op.
- `test_if_branch_routes_around_failure` — end-to-end recovery.
- `test_gate_abort_still_halts_with_continue_on_error` — abort
  always halts.
- `test_validation_rejects_non_bool_continue_on_error` — `"true"`
  rejected at validation.
- `test_validation_accepts_bool_continue_on_error` — `true`/`false`
  pass cleanly.
- `test_engine_ignores_truthy_non_bool_continue_on_error` —
  defense-in-depth: engine ignores string `"true"` even when
  validation is bypassed.

Rebased onto current upstream/main (post github#2664 merge); the new
`TestContinueOnError` class sits immediately after upstream's
`TestContextRunId` so the two feature suites coexist cleanly.

Closes github#2591.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Expose run_id as a workflow template variable

3 participants